import type { TSESTree } from "@typescript-eslint/utils";
import { createRule } from "../util.js";
import {
  ReportFixFunction,
  RuleContext,
} from "@typescript-eslint/utils/ts-eslint";
import { AST_NODE_TYPES } from "@typescript-eslint/utils";
import type ts from "typescript";

/**
 * Rule to enforce explicit table names in database calls
 * (db.get, db.replace, db.patch, db.delete)
 */
export const explicitTableIds = createRule({
  name: "explicit-table-ids",
  meta: {
    type: "suggestion",
    docs: {
      description:
        "Database operations should include an explicit table name as the first argument.",
    },
    messages: {
      "missing-table-name":
        "Database {{method}} call should include an explicit table name as the first argument. Expected: db.{{method}}({{tableName}}, ...) ",
      "missing-table-name-no-inference":
        "Database {{method}} call should include an explicit table name as the first argument. Expected: db.{{method}}(<tableName>, ...).",
    },
    schema: [],
    fixable: "code",
  },
  defaultOptions: [],
  create: (context) => {
    const filename = context.filename;
    const isGenerated = filename.includes("_generated");
    if (isGenerated) {
      return {};
    }

    const services = context.sourceCode.parserServices;
    if (
      !services?.program ||
      !services.esTreeNodeToTSNodeMap ||
      typeof services.esTreeNodeToTSNodeMap.get !== "function"
    ) {
      // Type information not available
      return {};
    }

    const checker = services.program.getTypeChecker();
    const tsNodeMap = services.esTreeNodeToTSNodeMap;

    // Get DatabaseReader and DatabaseWriter types for proper subtype checking
    // We need to find these types in the type system
    let anyDatabaseReader: ts.Type | null = null;
    let anyDatabaseWriter: ts.Type | null = null;

    try {
      // Try to get the database types from the program
      const sourceFiles = services.program.getSourceFiles();
      for (const sf of sourceFiles) {
        if (sf.fileName.includes("_generated/server")) {
          const sourceFileSymbol = checker.getSymbolAtLocation(sf);
          if (sourceFileSymbol) {
            const exports = checker.getExportsOfModule(sourceFileSymbol);
            for (const exp of exports) {
              const type = checker.getTypeOfSymbolAtLocation(exp, sf);
              const typeString = checker.typeToString(type);
              if (
                typeString.includes("DatabaseReader") ||
                typeString.includes("GenericDatabaseReader")
              ) {
                // Get the type that has the methods we care about
                const dbProp = type.getProperty("db");
                if (dbProp) {
                  const dbType = checker.getTypeOfSymbolAtLocation(dbProp, sf);
                  anyDatabaseReader = dbType;
                }
              }
              if (
                typeString.includes("DatabaseWriter") ||
                typeString.includes("GenericDatabaseWriter")
              ) {
                const dbProp = type.getProperty("db");
                if (dbProp) {
                  const dbType = checker.getTypeOfSymbolAtLocation(dbProp, sf);
                  anyDatabaseWriter = dbType;
                }
              }
            }
          }
          break;
        }
      }
    } catch {
      // If we can't get the types, we'll fall back to pattern matching
    }

    return {
      CallExpression(node: TSESTree.CallExpression) {
        // Check if it's a property access (db.get, db.replace, etc.)
        if (node.callee.type !== AST_NODE_TYPES.MemberExpression) {
          return;
        }

        const memberExpr = node.callee;
        if (memberExpr.property.type !== AST_NODE_TYPES.Identifier) {
          return;
        }

        const methodName = memberExpr.property.name;
        const validMethods = ["get", "replace", "patch", "delete"];
        if (!validMethods.includes(methodName)) {
          return;
        }

        // Check if the object is a database by checking its type or pattern
        const objectTsNode = tsNodeMap.get(memberExpr.object);
        const objectType = checker.getTypeAtLocation(objectTsNode);

        // Use proper subtype checking if we have the database types available
        let isDatabaseType = false;
        if (anyDatabaseReader || anyDatabaseWriter) {
          isDatabaseType =
            (anyDatabaseReader !== null &&
              methodName === "get" &&
              checker.isTypeAssignableTo(objectType, anyDatabaseReader)) ||
            (anyDatabaseWriter !== null &&
              checker.isTypeAssignableTo(objectType, anyDatabaseWriter));
        } else {
          // Fall back to string matching if we couldn't get the types
          const typeString = checker.typeToString(objectType);
          isDatabaseType =
            typeString.includes("DatabaseReader") ||
            typeString.includes("DatabaseWriter") ||
            typeString.includes("GenericDatabaseReader") ||
            typeString.includes("GenericDatabaseWriter");
        }

        // Also check for common patterns like ctx.db
        const isCtxDb =
          memberExpr.object.type === AST_NODE_TYPES.MemberExpression &&
          memberExpr.object.property.type === AST_NODE_TYPES.Identifier &&
          memberExpr.object.property.name === "db";

        if (!isDatabaseType && !isCtxDb) {
          return;
        }

        // Check the number of arguments to determine if it's unmigrated
        const args = node.arguments;
        const isUnmigrated =
          (methodName === "get" && args.length === 1) ||
          (methodName === "replace" && args.length === 2) ||
          (methodName === "patch" && args.length === 2) ||
          (methodName === "delete" && args.length === 1);

        if (!isUnmigrated) {
          return;
        }

        // Try to get type information for the first argument
        const tsNode = tsNodeMap.get(args[0]);
        const type = checker.getTypeAtLocation(tsNode);

        let tableName: string | null = null;

        // Try to extract table name from Id<"tableName"> type
        if (type.aliasSymbol?.name === "Id") {
          // Type with alias type arguments (internal TypeScript API)
          const typeWithArgs = type as ts.Type & {
            aliasTypeArguments?: readonly ts.Type[];
          };
          const typeArgs = typeWithArgs.aliasTypeArguments;
          if (typeArgs && typeArgs.length === 1) {
            const tableType = typeArgs[0];
            // Check if it's a string literal type
            if (tableType.isStringLiteral && tableType.isStringLiteral()) {
              tableName = tableType.value;
            } else if (tableType.flags & (1 << 7)) {
              // StringLiteral flag = 128 = 1 << 7
              // Fallback for different TypeScript versions
              const stringLiteralType = tableType as ts.StringLiteralType;
              tableName = stringLiteralType.value;
            }
          }
        }

        // Report the issue
        if (tableName) {
          context.report({
            node,
            messageId: "missing-table-name",
            data: {
              method: methodName,
              tableName: JSON.stringify(tableName),
            },
            fix: createTableNameFix(context, node, tableName),
          });
        } else {
          context.report({
            node,
            messageId: "missing-table-name-no-inference",
            data: {
              method: methodName,
            },
          });
        }
      },
    };
  },
});

/**
 * Creates a fix that inserts the table name as the first argument
 */
function createTableNameFix(
  context: RuleContext<string, unknown[]>,
  call: TSESTree.CallExpression,
  tableName: string,
): ReportFixFunction {
  return (fixer) => {
    const firstArg = call.arguments[0];
    if (!firstArg) return null;

    const tableNameString = JSON.stringify(tableName);
    return fixer.insertTextBefore(firstArg, `${tableNameString}, `);
  };
}
